Skip to content

http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests - #46528

Open
Jarred-Sumner wants to merge 136 commits into
mainfrom
claude/http-consolidated
Open

Jarred-Sumner wants to merge 136 commits into
mainfrom
claude/http-consolidated

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

In progress. This text can lag a push behind.

Consolidates the open pull requests for fetch, Request / Response / Headers, Bun.serve, node:http, node:http2, HTTP/3, node:quic and the undici / node-fetch shims into one.

This branch is built on top of #46522 (node:net, node:tls, usockets) and contains its commits, so #46522 should be merged first. Until then the diff here shows both. Everything below is about this PR's own changes.

All 642 open PRs in that area were checked against Bun 1.4.4, which was main when the work started. For node:* behavior the same script ran on Node 26.12.1 first, and Node's answer decided. 146 PRs are included in part, 48 were already fixed, 15 are duplicates, 113 were skipped and 89 are left for a maintainer to decide. All the others are included whole, usually rewritten smaller.

After the ports, a second pass over the branch gave each rule one owner. A check that two layers made is now made once, in the layer that owns the data, and code that nothing calls any more is deleted with its tests. So this PR's own diff removes more than it adds: +21,450 -30,166 lines under src/ and packages/, a net 8,716 fewer. In the diff, req.socket is a plain net.Socket / tls.TLSSocket. node:http has one implementation per kind of connection (uWS for listen(), a port of Node's own connection code for every other stream) and one queue of waiting responses. node:http2 has one stream table and shares its wire types with fetch's HTTP/2 client, fetch has one table of in-flight requests, and Bun.serve, fetch and node:quic share one QUIC transport layer. Facts about a body (it was a string, fetch() decoded it, its type) travel with the body.

The body is at GitHub's size limit, so these lists are in comments below: every behavior change with its 1.4.4 behavior next to it (it continues in a second comment), after it the PRs that are not included, and what was added since.

Read these first

These are the changes most likely to be noticed by a program that works today.

Bun.serve

fetch, Request, Response

node:http

node:http2

HTTP/3 and node:quic

undici and node-fetch

Where this PR still differs from Node on purpose, because 1.4.4 programs rely on it or because Bun's answer is the safer one:

  • node:http: a socket nobody reads closes when its connection is gone, server.unref() also lets go of sockets that 'upgrade' or 'connect' got, and req.socket.remoteAddress = x sticks.
  • node:http: a connection that closes under an ended response's last write still emits 'finish'. On listen() connections res.cork() has no effect and the head is always Latin-1.
  • node:http: a connection that reported a parse error is closed about 10 s later, even if the 'clientError' listener leaves it open. A body under a non-chunked Transfer-Encoding is sent with Connection: close.
  • node:http2: without an 'error' listener, a late write() on a response that respond() ended is dropped, a pushed stream's error isn't thrown, and an early NO_ERROR reset drops later writes. A client session with no 'stream' listener cancels pushes.

Fixes

node:quic

HTTP/3

node:http2

undici and node-fetch shims

node:http and node:https

Bun.serve

fetch, Request, Response, Headers

Other

Tests, docs and types

Deflakes, faster test files, new coverage, docs and type fixes: #24276, #31354, #32755, #33787, #34833, #35419, #35424, #35744, #35921, #35929, #36153, #36154, #36207, #36376, #36444, #36890, #36945, #36946, #37209, #37433, #37439, #37475, #37806, #37809, #37818, #37832, #37910, #37913, #37935, #38470, #38490, #39042, #39068, #39903, #39948, #39950, #39951, #40083, #40088, #40090, #40197, #40297, #40451, #40453, #40539, #40890, #41111, #41336, #41427, #41919, #41951, #42156, #42206, #42255, #42985, #43627, #44069, #44076, #44080, #44113, #44203, #44256, #44308, #44423, #44427, #44927, #44978, #44979, #45428, #45773, #45953, #46027.

How did you verify your code works?

  • Each fix has a test that fails on 1.4.4 and passes on the branch. Bugs only a sanitizer sees were checked against a debug + ASAN build of main. A few tests can't fail on Linux (macOS and Windows fixes), and a few pass on both builds because they guard what the second pass could have broken.
  • CI at 9d5ba06: every static check and every lane that has machines pass (Linux, the sanitizer lane, Windows, macOS arm64). Not done yet: the macOS x64 lane has had no machine since be1b3a2, and there's no release benchmark of the final state. The source lints, tsc on the three projects and the check of the published types against the schemas pass.
  • For node:* changes, the same script prints the same on Node 26.12.1 and on the branch, apart from the differences listed in the behavior changes. The shims were compared with undici 6.29.0 / 7.30.0 and node-fetch 2 / 3 under Node in the same way. The Node build used has no node:quic, so Node's answers there come from its source and from the original PRs' runs.
  • Test files, compared by test name on debug + ASAN builds. Before the second pass, about 1,400 files against main. During it, each group of changes against the build before it: the plain req.socket 236 files (227 pass, and the other 9 also fail on main or need Docker, the internet or a non-root user), node:http responses 108 files (6,261 tests pass, 3 failed: one is fixed since, one belongs to #46522, one needs a non-root user), Bun.serve 106 files (4,845 pass, 1 needs a non-root user), bodies 121 files (7,553 pass, 2 fail: that one, and a 10 s limit on a loaded machine), fetch and the shims 112 files (6,897 tests, 2 failed and are fixed), node:http2 13 files (1,232 pass).
  • Node's own tests during the second pass: 465 of 467 test-http-* and test-https-* files (the other 2 fail on main too), 257 of 259 http2 files (the other 2 pass when run separately), 305 of 311 test-quic-* and test-dgram-* files (6 failed before it too, 5 of them the same).
  • HTTP client: 2,406 scripted rows differ in 3 from the build before the pass (one connection saved, two races). 1,200 random response streams: no outcome differs. 1,650 random redirect chains: none differs from that build or from Node. Of 90 shapes of Location, 83 equal Node and 7 are as on 1.4.4.
  • Request parsing: the old and the new parser in one ASAN + UBSAN binary, 6,000 request streams split at every byte offset, 1.74 million runs: no difference. 8,000 trailer sections against an independent strict parser, 896,000 runs: no mismatch.
  • Tables: 1,116 raw requests to Bun.serve (header order, letter case and the Content-Encoding change differ, plus 2 rows by timing), 2,870 rows of Bun.serve options (only an internal key differs), 44 failed listens (all 22 node:* rows equal Node, none did on 1.4.4), 1,440 rows of node:http response bytes (450 changed, all to Node's), 798 TLSSocket members of an https req.socket (all equal a tls.Server socket's), 308 proxy shapes (78 lose a false Content-Encoding, none a true one), 7,104 node:http2 teardown cases (2 differ, listed under #43433), 35 ways to end a server under LeakSanitizer (no leak).
  • Random programs: before the second pass, 8,830 programs (24,228 runs on 1.4.4, Node and the branch) found six unintended differences, all fixed. During it, each group reran the families it touches, 240 to 1,300 programs each: no crash, sanitizer report or new hang. What they found is fixed, for example a graceful stop() that waited 16 s for a client.
  • The 1,077 identifiers the pass had deleted when #46522 was merged in were searched for in every tracked file, the exported ones also in the prebuilt WebKit and the vendored libraries. One deletion broke the link and was repaired.
  • Syscalls (strace): a node:http connection costs the same 8 calls as before the pass, and 800 Bun.serve keep-alive requests the same sendto, recvfrom and epoll_pwait2 counts.
  • Speed, on CI's release builds from before the second pass, with main against a copy of itself as the noise control (typically 1 to 3 %). Bun.serve over HTTP/1.1 and node:http hello world were within noise. fetch keep-alive (plain, TLS, and with Keep-Alive: timeout=5) was within 0.7 % of main in cycles per request, with 2 to 4 % fewer user instructions. Slower: a node:http2 server with 100 streams (1 to 2.5 % more cycles) and a node:http2 client downloading 1 MB bodies (4 to 5 %). require("node:http") took 1 % longer and the binary grew by 418 KB.
  • Speed during the pass. On CI's profile build with about a third of the node:http2 work: a server's 300,000 GETs take 4.0 % fewer user instructions than main (it was 3.7 % more), and a client's 1 GB download 0.3 % more cycles (it was 5.0 %). uWS by itself, compiled with release-like flags on stubbed sockets, in instructions per request: hello world 3,537 to 2,901, 16 headers 8,959 to 4,912, a static route 3,996 to 3,134, a file 3,875 to 2,786.
  • A known cost, not measured on a release build: a node:http response that has to wait for its turn (a pipelined request with an async handler) takes about twice the instructions. Without pipelining, or with a synchronous answer, it's the same or cheaper.
  • Same results before and after the pass: http-proxy, socket.io, supertest, koa, got, svelte, vite 6.2's dev server with HMR, hono, express, body-parser, @fastify/websocket. elysia, grpc-js and http2-wrapper fail what they failed before it, plus one elysia test that sleeps 3 ms. Before the pass, fastify, ws, axios, superagent and miniflare matched 1.4.4 too.
  • Windows, macOS and FreeBSD code is compiled and run by CI only: the half-close under kqueue, a socket's hand-off on libuv, the QUIC timer and the send loop without sendmmsg, the named pipe's listen error, and #46434's collection on Windows, whose test is skipped there. The HTTP/3 stream reset has no test here, because no lsquic peer can send that stream. It was checked against another QUIC implementation.

@Jarred-Sumner
Jarred-Sumner requested a review from alii as a code owner October 22, 2026 15:47
@robobun

robobun commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 11:37 AM PT - Nov 11th, 2026

✅ @claude, your commit 3f5d7cd7bd112049ab2a3d24dcba3cf8330e7361 passed in Build #140202! 🎉


🧪   To try this PR locally:

bunx bun-pr 46528

That installs a local version of the PR into your bun-46528 executable, so you can run:

bun-46528 --bun

Comment thread src/js/thirdparty/undici.js Resolved
Comment thread src/js/thirdparty/undici.js Resolved
Comment thread src/resolver/data_url.rs Resolved
Comment thread packages/bun-uws/src/HttpResponse.h Resolved
Comment thread src/http/h3_client/callbacks.rs Resolved
Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved
Comment thread src/http/h3_client/AltSvc.rs Resolved
Comment thread packages/bun-uws/src/Http3Context.h Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, two other candidates were examined and ruled out: the Keep-Alive: timeout=N handling in src/http/lib.rs (around line 5224) drops pooling only when N minus the 2 s margin reaches zero, matching undici's keepAliveTimeoutThreshold semantics rather than disabling reuse broadly; and the +32 per-field estimate in lshpack_wrapper_encode_block (src/jsc/bindings/c-bindings.cpp:588) is backed by lshpack_enc_encode being bounded by the end pointer, so an undersized buffer surfaces as the SIZE_MAX error return, not an overflow.

Extended reasoning...

The change is a 404-file, roughly 44k-line consolidation across fetch, Bun.serve, node:http, node:http2, HTTP/3, node:quic and the undici/node-fetch shims, including new lsquic/lsqpack patches and uWS/usockets C/C++ changes, which is TLS- and parser-adjacent security-sensitive surface. Ten inline findings are being posted, so a human review is already signaled; this body only records two further candidates checked against the code and found not to be bugs.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/runtime/webcore/fetch.rs:1668 — Programs that pass a FIFO, a device or /dev/stdin via Bun.file() as a fetch() body now buffer the whole source in memory before sending; a long-lived producer grows RSS until the process is killed. The non-regular path breaks out of sendfile at src/runtime/webcore/fetch.rs:1661 into a full read, and src/runtime/node/node_fs.rs:7289-7290 marks the size unknown and drops has_max_size, so the read runs to EOF with no cap. The base stopped at 262,160 bytes; the only brake is the Linux-only ENOMEM check at node_fs.rs:6879, which fires at total system memory. Fix: stream non-regular bodies (chunked transfer) or keep a bounded in-memory cap and reject beyond it with a clear error, on every platform. [also at: src/runtime/node/node_fs.rs:7291 - A fetch() or Response whose body is Bun.file() over a pipe, FIFO or character device now buffers the whole stream in memory before sending; on the base branch the read stopped at 262,160 bytes.]

    Why this was flagged

    The trigger is any fetch() whose body is Bun.file() over a non-regular file: Bun.file("/dev/stdin") piped from a long producer, a named pipe, or a device, entering through fetch() at src/runtime/webcore/fetch.rs:1661 where !S::ISREG leaves the sendfile path and the body is read into memory. In the read path src/runtime/node/node_fs.rs:7289 sets size_unknown for any non-regular file and src/runtime/node/node_fs.rs:7290 clears has_max_size, so the loop reads until EOF with no limit. On the base branch the read stopped after 262,160 bytes (wrong, but bounded); after this change a slow or endless producer makes RSS grow without bound and the process is OOM-killed. The safeguard should_throw_out_of_memory_early_for_javascript (node_fs.rs:6879) only triggers at the machine's total memory and only on Linux, so on macOS and in containers with cgroup limits the kill arrives first. Remedy: stream such bodies with chunked encoding, or enforce a bounded cap and surface an error.

    Verification: src/runtime/webcore/fetch.rs:1661-1663 breaks out of sendfile for every non-regular fd. src/runtime/node/node_fs.rs:7289-7290 make has_max_size false, so the growth arm at 7363-7378 keeps extending the Vec until read returns 0. On base the loop stopped at 262,160 bytes. Lines 7291-7292 set limit_size_for_javascript, so node_fs.rs:6879-6899 rejects with ENOMEM once total > synthetic_allocation_limit().

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread packages/bun-uws/src/HttpParser.h Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/js/node/http2.ts Resolved
Comment thread packages/bun-uws/src/HttpContext.h Resolved
Comment thread src/js/thirdparty/undici.js Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/http_types/Method.rs Resolved
Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/js/node/http2.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the latest push (76bc84e) and found no new bugs; given the size of this PR (411 files across fetch, Bun.serve, node:http/http2, HTTP/3 and the uWS/usockets parsers), a human look is still worthwhile before merge.

What was reviewed this round:

  • The two threads left open from the previous push: flushHeaders() now completes the head of a body-less response after write() (src/js/node/_http_server.ts), and the HTTP/2 write() drop now covers a synchronous handler answering HEAD like GET (src/js/node/http2.ts).
  • Two divergences from Node in the same commit were weighed and judged nits: a write after the user's own end() with no 'error' listener now goes to the callback instead of an uncaught 'error', and setHeader/setTrailer silently drop keep-alive/transfer-encoding/upgrade/http2-settings/proxy-connection/non-trailers te where Node throws from respond() — both are documented in the diff comments, and a maintainer may want to confirm the intentional choice.
  • The http1_server_fallback head copy fix (headers?.slice()) correctly removes the shared-array reuse between consecutive responses.
Extended reasoning...

The latest commit touches src/js/node/_http_server.ts, src/js/node/http2.ts and src/js/internal/http1_server_fallback.ts plus tests, resolving the two inline findings from the prior run and consolidating connection-specific header handling into one isConnectionSpecificHeader helper. The overall PR touches security-sensitive surface: HTTP/1 request parsing in packages/bun-uws (Host/trailer/chunked validation), TLS option plumbing through undici dispatchers, and HTTP/3/QUIC window handling in native code. The hunt ran dry this round with only two Node-divergence nits ruled out, and all earlier threads were self-resolved by the author with follow-up commits that plausibly addressed them, but the sheer scope and the number of intentional behavior changes mean approval would require a maintainer's judgment rather than an automated one.

@robobun

robobun commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

A third regression of #44833 on main for the list in my note above. It is in the two aborted handlers (http2.ts:4138 and :5173 on main).

The client cancels a request (req.end(); req.close(8)) whose server stream the 'stream' listener has already closed or destroyed:

the server calls Node 26.3.0 Bun 1.4.4 main (98cda0d)
st.destroy() aborted, close aborted, finish, close aborted, close, aborted
st.close(2) aborted, finish, error, close aborted, finish, error, close aborted, finish, aborted, close
st.close(8) aborted, finish, end, close aborted, finish, close aborted, finish, aborted, close
st.destroy(err) aborted, error, close aborted, error, close, aborted aborted, error, close, aborted
  • The handlers emit 'aborted' when the wire state was open (old_state), and they set rstCode = 8 with no condition. Since bun test: Vitest compatibility #44833, close() and destroy() before respond() write nothing at once, so the peer's CANCEL finds the wire state open. The second 'aborted' can come after 'close', and close(2) loses its 'error'.
  • This branch reads as fixed: abortIfWritableOpen() tests kAborted and the closed bit, and keepsOwnRstCode() keeps the code of close(2). I did not run these rows on it.
  • For main, node:http2: emit 'aborted' only while the writable side is open #45056 has the rule for the event. A stopgap is one condition in each handler: with its two statements under a test of the closed bit, a debug build of main prints Node's line in rows 1, 2 and 4. I have not opened a PR for it. Say if you want it.

@robobun

robobun commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

node:http2: a server session loses a GOAWAY that comes in the same read as a rejected send() (on main since #44833, not on this branch)

No PR from me for this. This branch and #46522 already fix it. I built and ran both heads. Two tests are below, because I found no test for this case on either branch.

Tests, and what I ran

The tests go at the end of describe("session.destroy() with no close() before it") in test/js/node/http2/node-http2-client-close.test.ts. They use the settings and listen of that block. The same text applies to the file on main and on this branch.

  // On Windows a reset discards what the socket has not read, so the server never gets these frames.
  const skip = process.platform === "win32";
  function seenBy(server: http2.Http2Server) {
    const seen: string[] = [];
    server.on("stream", stream => {
      stream.on("error", () => {});
      seen.push("stream");
    });
    return new Promise<string[]>(resolve =>
      server.on("session", session => {
        session.on("error", () => {});
        session.on("goaway", code => seen.push(`goaway:${code}`));
        session.on("close", () => resolve(seen));
      }),
    );
  }

  // destroy() closes the socket one turn later, with the server's SETTINGS still unread, so the
  // kernel resets the connection. The server's SETTINGS ACK finds the reset inside the read that
  // carries the request and the GOAWAY.
  test("a server session gets the GOAWAY of a client destroyed with a request in flight", { skip }, async () => {
    const server = http2.createServer();
    const seen = seenBy(server);
    const port = await listen(server);
    try {
      const client = http2.connect(`http://127.0.0.1:${port}`);
      client.on("error", () => {});
      client.on("connect", () => {
        client.request({ ":path": "/" }).on("error", () => {});
        client.destroy();
      });
      assert.deepStrictEqual(await seen, ["stream", "goaway:0"]);
    } finally {
      server.close();
    }
  });

  test("a server session gets the GOAWAY that a peer sends before it resets", { skip }, async () => {
    const server = http2.createServer();
    const seen = seenBy(server);
    const port = await listen(server);
    const peer = net.connect(port, "127.0.0.1");
    try {
      peer.on("error", () => {});
      // The server's SETTINGS: its next send is the SETTINGS ACK.
      await new Promise(resolve => peer.once("data", resolve));
      peer.write(
        Buffer.concat([
          Buffer.from("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"),
          settings,
          // HEADERS on stream 1, END_STREAM | END_HEADERS: GET / over http, :authority localhost.
          Buffer.from([0, 0, 14, 1, 5, 0, 0, 0, 1, 0x82, 0x84, 0x86, 0x01, 0x09]),
          Buffer.from("localhost"),
          // GOAWAY: last stream 0, NO_ERROR.
          Buffer.from([0, 0, 8, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
        ]),
      );
      peer.resetAndDestroy();
      assert.deepStrictEqual(await seen, ["stream", "goaway:0"]);
    } finally {
      peer.destroy();
      server.close();
    }
  });

Results of the two tests, all on Linux x64. I did not run Windows or macOS.

Build Client destroy() Raw peer resets
Node v26.12.1, node --test pass pass
Bun 1.4.3 pass fail
main 98cda0d, canary release build fail fail
main 98cda0d with #42485 ported, debug build pass pass
#46522 at ddfd2d3, debug build pass pass
this branch at edb9ce8, debug build pass pass

With the tests added, the whole file gives 163 pass and 0 fail on this branch, and 82 pass and 0 fail on #46522.

On main. A syscall trace of the server shows one recv() of 75 bytes (preface, SETTINGS, HEADERS, GOAWAY), then send() of the SETTINGS ACK fails with EPIPE two times, then Bun closes the fd. No recv() returns 0. The H2FrameParser debug log of a debug build of main has this order inside one rewriteRead: 'remoteSettings', the flush whose send fails, 'stream', onNativeClose, the socket's 'end', 'finish' and 'close', the session's 'close'. There is no 'goaway'.

The GOAWAY is lost only when a JS dispatch comes between the close and the GOAWAY frame. The nextTick chain of the close runs at the checkpoint of that dispatch. END_STREAM on the HEADERS frame adds such a dispatch. With the same bytes and no END_STREAM, main delivers the 'goaway'.

On this branch. The same log has onNativeClose inside the read, after the stream's callbacks. The socket's 'end' and 'finish' follow. Then comes the session's 'goaway'. The socket's 'close' and the session's 'close' come after the read. So the close that on_auto_flush makes is still inside the read (h2_frame_parser.rs:1872). Only the teardown moved out of it. If the 'close' of an accepted socket goes back to process.nextTick, the GOAWAY is lost again.

One more probe. The server answers the request with respond() and end("hello"), and the raw peer sends the GOAWAY before it resets. The stream's events and the session's 'goaway' come in this order:

Build Events
Node v26.12.1 goaway:0, finish, close:0
this branch goaway:0, finish, close:0
#46522 finish, close:0, goaway:0
main finish, close:0
main with #42485 ported goaway:0, close:8

#42485 on main. The only conflict of the port is in dispatch.rs, where the arms of a task now go through a feature (NodeHttp2 here). With the port, these two tests of node-http2.test.js fail:

  • "a frame written after the transport closed counts as sent" gets ["close:8"] and expects ["finish", "close:0"].
  • "a session whose client is gone ends without an error on its streams, without TLS" gets ERR_HTTP2_ERROR on 9 of 10 streams.

Both rely on the close that #42485 moves: transport_send_rejected() returns false for a plain socket because "a plain socket is closed at once".

Order only, not a loss. In the one-process repro, the client session's 'close' prints after the server's 'stream' and 'goaway' on every Bun build since #44833. Node and Bun 1.4.3 print it first.

@robobun

robobun commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

#43753 (draft) overlaps this PR. It is the PR behind "(#43753, in part)" in the description: server.timeout and setTimeout() cut a node:http response that is still being written. It is a regression of 1.4.x. With server.setTimeout(100) and a write every 40 ms, 1.4.2 and main (2596ac5) end in ECONNRESET at 300 of 800 bytes or sooner. 1.3.13, Node v26.3.0 and edb9ce8 deliver all 800.

The head of #43753 does not have the mechanism of this branch. It hands the socket's Timeout to the native connection, refreshes it on each write that counts, and adds a flag to AsyncSocketData. This branch records the write and moves the deadline when the timer fires, and _http_server.ts:1655 says why. So I stopped that work.

I built edb9ce8 (release, Linux x64) and ran the test file of #43753 on it, as it is at a79f34f. 35 of its 38 new tests pass. That includes the 11 tests of a write that drains slowly (a slow reader, writes that do not wait for 'drain', https, a CONNECT tunnel). These 3 fail:

test edb9ce8 Node v26.3.0
the inactivity period after res.end() behind a complete body the call restarts the period: end() calls noteResponseWrite() when it sends nothing (_http_server.ts:3332) the call does not count
no 'timeout' from socket.setTimeout() on a connection that its peer has reset 'timeout' for a connection that has closed natively, before its 'close' no 'timeout'
no 'timeout' from keepAliveTimeout on a connection that its peer has reset the same no 'timeout'

Main fails the last two as well. They need a busy event loop at the moment of the reset.

Which order do you prefer?

It stays a draft until you say.

claude and others added 5 commits November 11, 2026 07:12
Main moved by 28 commits. 76 files conflicted.

- The first texts of net, tls, http2, the streams and process.stdin went back
  to their untyped form on main, with typed second texts under
  src/js/ahead-of-time. Both are merged here from the matching ancestor.
- src/js/ahead-of-time/node/_http_server.ts, _http_incoming.ts and
  _http_outgoing.ts keep this branch's connection code, with main's newer
  guards and births applied where the code that they guard is still there.
- internal/net/symbols exports `constructors` again: the first text of
  _http_server takes net.Socket's own class from it.
- The native side takes main's safer signatures (lsquic::Conn, run_corked,
  HiveArray::Fallback, SNI out-parameters, Listener cells) and keeps this
  branch's behavior. Body.json() follows main's removal of jsonSource.
- call_write_complete moved from NewSocket<SSL> to Handlers.

Co-authored-by: jarred <jarred@anthropic.com>
- The second text of _http_server asks [kFailHeldWrites] in front of the quick
  finish of a response, and reads no [is_tls] off a server at listen().
- A server socket's remoteAddress, remotePort and remoteFamily are undefined on
  an object that got their descriptors and is no socket. They threw there.
- spyOn() reads a lazy export of a built-in module only where that is a
  property of a module: spyOn(Map.prototype, "size", "get") threw.
- The table says again that QuicStream's initStreamingSource() writes the
  state's buffer. The stream's async iterator asks of the Readable that it has.
- ERR_HTTP2_TOO_MANY_ORIGINS is listed once.
- Bun.serve without TLS calls no function of the TLS code, and node:dgram builds
  the error of a failed receive without an object literal, as in #46522.
- Stored programs and lists follow the branch: a connection is resumed once when
  it opens, as in Node; setHeader() of a response is OutgoingMessage's; what
  waits for its turn lies under kConnectionState; a connection handed over has
  no native handle; the class above net.Socket and the HTTP/1 fallback are gone.

Co-authored-by: jarred <jarred@anthropic.com>
One import conflicted, in native-bindings.builtins.ts. The fixes of #46522 for
the Redis client under --trim and for the counts of the engine's IR tests are
here too.

Co-authored-by: jarred <jarred@anthropic.com>
Co-authored-by: jarred <jarred@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

…d have a module of their own

Every compiled program loads internal/shared, and lowered their three functions
with it: three programs that allow 600 functions had 601.

Co-authored-by: jarred <jarred@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread src/js/internal/cluster/primary.ts Resolved
claude and others added 2 commits November 11, 2026 14:13
…ode:cluster

The primary sends such a worker a message that nobody reads. It closes the
channel itself where the worker never said that it is online.

Co-authored-by: jarred <jarred@anthropic.com>
Co-authored-by: jarred <jarred@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

@robobun

robobun commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

The stand-in test of --isolate: green in build 140202, and what holds the first file's global on a debug build

CI. a stand-in does not keep the test file that read it passed on x64-asan in #140202 (3f5d7cd). It failed 4 of 4 attempts in #140151 and #140169. Between ee19cba and 3f5d7cd the source changes by two lines of internal/cluster/primary.ts. So the result follows the binary, and the new lines of the test had nothing to report.

A debug build has one holder. I built main (447ff87) debug with bun bd on linux-x64. bun-test-isolate-compiled.test.ts fails 6 of 24 tests there: each "does not keep the test file" test. ee19cba fails the same six. I ran the files of the stand-in test by hand on main:

  • The reads of d.test.ts pass. f.test.ts fails at objectTypeCounts.BigInt64Array: 1.
  • A d.test.ts that only sets the mark gives the same result, also without --aot.
  • A heap snapshot in the last file: the first file's global holds the array through mark. 994 cells reach it. None of them is in roots.
  • The stack at that collection: 7 words hold the address of that global. All 7 are in the frame of TestCommand::exec. No other stack word points at one of the 994 cells.
  • I set the 7 words to 0 in gdb before the collection. Then the run prints BigInt64Array 0, globalObjectCount 1 (3 of 3 runs). Untouched, it prints 1 and 2 (2 of 2 runs).

The first file runs in the global that the process starts with. exec stays on the stack for the whole run.

Limits. This is a debug build. I could not run the x64-asan binary of build 140169. That its red came from the same frame is a guess.

Also on the debug build: the compiler can crash. SmallSetsInLinkFlow::numberOf (AOTLinkFlow.h:320 at WebKit 264cf6db41) keeps its AddResult past the release of its lock (line 332). With the iterator checks of WTF, the add of another lane clears the table of that iterator. Then removeIterator reads through null (HashTable.h:1638, SEGV at 0x8). The node:http test of this file hit it in 1 of 4 runs on main. On ee19cba it hit it in 1 of 1. AOTLinkFlow.cpp:2976 and :7450 have the same shape. WebKit main has the same lines.

The words, the snapshot, the crash

Debug build of main 447ff87, bun-debug test --isolate ./d.test.ts ./e.test.ts ./g.test.ts with the environment of bunEnv. d.test.ts sets globalThis.mark = new BigInt64Array(1) and has one empty test. g.test.ts is f.test.ts plus generateHeapSnapshotForDebugging().

The 7 words, found at the entry of JSC__VM__runGC in the last file. The frame is the one that returns into TestCommand::exec (58 of 63 in the frame-pointer chain, rbp 0x7fffffffd950):

rbp-0xb0  rbp-0xa8  rbp-0xa0  rbp-0x98  rbp-0x90  rbp-0x88      six adjacent words
rbp-0xc0b0                                                       one more

They lie outside the 519 locals that the ASAN description of the frame lists (0x7fffffff2d80 to 0x7fffffffc358).

Roots of the snapshot: StrongReferences 289, StrongHandles 147, DOMGCOutput 1, JITWorkList 1, ProtectedValues 1. The two GlobalObject cells are the first file's and the current one.

The six tests that fail on the debug build of main: heapStats(), a regular expression with named groups, import() calls a job apart, a read that never ends, a stand-in, bun:sql. The node:http one passed there in the run of the whole file.

The crash, thread "AOT build":

AddressSanitizer: SEGV on unknown address 0x000000000008 (READ)
#0 WTF::removeIterator<...>                                   wtf/HashTable.h:1638:37
#1 WTF::HashTableConstIterator<...>::~HashTableConstIterator
#3 WTF::HashTableAddResult<...>::~HashTableAddResult
#4 JSC::AOT::Typed::SmallSetsInLinkFlow<ID<LayoutIDTag>, 4>::numberOf   aot/facts/AOTLinkFlow.h:334:5
#5 JSC::AOT::Typed::LinkFlow::typeOfLayouts                   aot/facts/AOTLinkFlow.cpp:1215:44
#6 JSC::AOT::Typed::LinkFlow::foldObjects                     aot/facts/AOTLinkFlow.cpp:3643:30
#7 JSC::AOT::Typed::LinkFlow::receiveInLane                   aot/facts/AOTLinkFlow.cpp:3935:13
#8 JSC::AOT::Typed::LinkFlow::goAlongEdges()::$_0::operator() aot/facts/AOTLinkFlow.cpp:3980:21

On ee19cba it came through typeOfFunctions (AOTLinkFlow.cpp:1227) and foldFunctions (:3776). In removeIterator the iterator waits for the mutex of the table while invalidateIterators sets its m_table to null. Line 1638 then reads it->m_table->m_iterators.

Copy link
Copy Markdown
Collaborator Author

The four red Rust checks on 3f5d7cd (clippy, miri, mordant, lol-html) failed in their setup step, before any check ran. The runner could not resolve security.ubuntu.com while apt installed packages (log). I re-ran them once and they failed in setup again. All four passed on ee19cba, and the two commits since then change no Rust. There is nothing to fix in this PR; they need another run now that the outage may be over.


Generated by Claude Code

@robobun

robobun commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

The four Rust checks pass on 3f5d7cd now. I ran the failed jobs again at 16:46Z: clippy, miri and lol-html passed, and mordant failed in Setup once more. A second run at 17:37Z passed mordant too.

Buildkite #140202 has 184 jobs passed and none failed. Its four macOS x64 test jobs expired once with no agent and are queued again.

Copy link
Copy Markdown
Collaborator Author

robobun/evidence rejects 3f5d7cd for TODO/FIXME markers in added lines. There are five:

  • src/runtime/ipc.rs, src/runtime/server/RequestContext.rs and src/runtime/webcore/Body.rs: one each. These files have the same number of TODOs as on main (4, 13 and 1); the diff only changes the code on those lines or moves it.
  • test/js/node/test/sequential/test-http2-ping-flood.js: the marker is in Node's own text. The file is identical to v26.5.0.
  • test/js/node/http/node-http.test.ts: the two it.todo cases for removeAllListeners() followed by a new 'data' listener. They are the open optional thread on _http_server.ts, left for a maintainer to decide.

No push for this: the Node file would keep the gate red anyway.


Generated by Claude Code

@robobun

robobun commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

All five are as you list them. I counted the markers in the three source files on main and on this branch: 4, 13 and 1 on both. The Node file is new here, and its line 39 is Node's text.

No push is needed for this status. I asked the people who run that check to turn it off for this PR.

@robobun

robobun commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

#43424 (draft) overlaps this PR. It is in the Skipped list of the comment of 23 October. It is for a regression of the 1.4.x releases in node:http2: after a write() that follows end() or close(), a stream never emits 'close', and its peer gets neither END_STREAM nor RST_STREAM. A real program reaches it: grpc-js.

Node v26.12.1 Bun 1.3.14 Bun 1.4.4
1. server write("done"); end(); write("late"), frames after HEADERS DATA with END_STREAM DATA, END_STREAM DATA, then nothing
2. server write("done"); close(); write("late") DATA, RST_STREAM(0) DATA, END_STREAM, RST_STREAM(0) DATA, then nothing
3. maxConcurrentStreams: 2, two streams of row 1, then a third request answered answered refused, RST_STREAM(REFUSED_STREAM)
4. grpc-js 1.12.0 client, server limit of 2 streams: two bidi calls whose application writes after the server finished, then unary calls ok ok DEADLINE_EXCEEDED, the requests stay pending

What this branch has at 3f5d7cd. I read it. I did not build it.

  • The 'close' half, in a smaller shape than node:http2: emit 'close' and end the stream after a write() that follows end() or close() #43424: destroyClosedStream(session, stream) registers destroySelfIfErrored on errorMonitor where the destroy waits for 'end' (src/js/node/http2.ts:4967-4987). With those lines grafted onto main's http2.ts, row 4 passes. main fails it.
  • One gap in that half: a stream that errors while the destroy waits for 'finish' (:4983) has no such listener.
  • Not the wire half. close() leaves the reset of NO_ERROR to 'finish' (:2654), which an errored stream never emits. I found nothing that sends the END_STREAM that _final owed. So I expect rows 1 to 3 as on 1.4.4.

The head of #43424 is not fit to merge, and I made it a draft:

  • A review found three defects in it. Its description has them, with the stacks.
  • Its native part is written against end_stream and data_frame_queue, which this branch does not have. A trial merge with 3f5d7cd conflicts in all five of its source files.

Which order do you prefer?

It stays a draft until you say.

One more thing, not about #43424. On main, Stream::flush_queue keeps a &mut Stream across a transport write. Over tls.connect({ socket: <JS Duplex> }), a write() and then resume() end in a heap-use-after-free under ASAN. The stack is set_stream_reading, flush, flush_stream_queue, flush_queue, and rewrite_read frees the stream. I do not know if this branch has it.

Copy link
Copy Markdown
Collaborator Author

On the use-after-free: this branch does not have it. flush_stream_queue walks a snapshot of Rc<Stream>, so a stream that JavaScript closes under the transport write stays alive. flush_queue also takes the frame (or a copy of its chunk) out of the queue before it writes. I ran writes, resume(), close() and destroy() over tls.connect({ socket: <JS Duplex> }) under ASAN on 44c6245 plus the test commit, 12 runs, with no report. That program is mine, not your reproduction.

The order of #43424 and this PR is for the maintainers to choose.


Generated by Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment